Skip to content

Add seal-day backend action for daily snapshots#1037

Merged
Chris0Jeky merged 14 commits intomainfrom
paper/1017-seal-day
Apr 27, 2026
Merged

Add seal-day backend action for daily snapshots#1037
Chris0Jeky merged 14 commits intomainfrom
paper/1017-seal-day

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

Closes #1017. Implements the backend 'seal day' snapshot action required by PAPER-08.

  • Domain: DailySnapshot entity with Seal(DateTimeOffset) (idempotent -- second invoke is a no-op), IsSealed computed property, validation (no future dates, non-empty userId)
  • Application: IDailySnapshotRepository interface (user+date lookup, sealed-days range query), IDailySealService/DailySealService with get-or-create + idempotent seal logic, response DTOs
  • Infrastructure: DailySnapshotRepository, EF Core configuration with unique index on (UserId, Date), EF migration for DailySnapshots table, DI wiring in UnitOfWork and DependencyInjection
  • API: TodayController with POST /api/today/seal (seal a day) and GET /api/today/seal?date=... (check seal status), claims-first identity via AuthenticatedControllerBase
  • Tests: 10 domain tests (construction, seal idempotency, future date rejection, IsSealed), 10 service tests (seal new/existing/already-sealed, validation, status checks)

Adversarial self-review findings

  1. Concurrency (two simultaneous seal requests): The unique index on (UserId, Date) provides database-level protection against duplicate snapshots. If two requests race to create a snapshot for the same user+date, one will get a DbUpdateException caught by UnitOfWork.SaveChangesAsync which wraps it in a DomainException with Conflict error code. The Seal() method itself is idempotent, so if both requests find an existing row, both succeed harmlessly.

  2. Date timezone handling: The service uses DateTimeOffset.UtcNow consistently for the now parameter. The DateOnly comparison in the domain constructor uses DateOnly.FromDateTime(now.UtcDateTime) to ensure UTC-based date boundary. The date parameter is DateOnly (no timezone ambiguity). The API accepts DateOnly from the request body/query, which serializes as YYYY-MM-DD with no timezone component -- the caller decides which calendar day to seal.

  3. Migration correctness: The migration creates the table with the correct columns, primary key, and unique composite index on (UserId, Date). The Down() method drops the table cleanly.

  4. Idempotency guarantee: Enforced at two levels -- domain (Seal() is a no-op if already sealed) and service (returns WasAlreadySealed: true without mutation). The UpdatedAt concurrency token prevents lost updates if the row changes between read and write.

  5. Missing edge case: No explicit transaction wrapping in SealDayAsync for the create-then-seal path. With EF Core change tracking, both the AddAsync and the implicit Seal() mutation are persisted in a single SaveChangesAsync call, which is atomic. This is safe.

Test plan

  • Domain: DailySnapshot creation with valid/invalid inputs
  • Domain: Seal idempotency (multiple calls preserve original timestamp)
  • Domain: Future date rejection
  • Domain: IsSealed computed property
  • Service: Seal new day creates and seals snapshot
  • Service: Seal existing unsealed day seals it
  • Service: Seal already-sealed day returns idempotent response
  • Service: Empty userId validation
  • Service: Future date validation
  • Service: GetSealStatus for missing/sealed/unsealed snapshots
  • Build: dotnet build backend/Taskdeck.sln -c Release passes (0 errors)
  • Tests: All 20 new tests pass

Introduces DailySnapshot entity for the seal-day action (#1017).
Includes validation (no future dates, non-empty userId), idempotent
Seal() method, and computed IsSealed property.
Application layer for seal-day (#1017): repository interface with
user+date lookup and sealed-days range query, service interface with
seal and status check, and service implementation with get-or-create
idempotent seal logic.
Adds DailySnapshotRepository, EF configuration with unique index on
(UserId, Date), DbContext registration, UnitOfWork integration, DI
wiring, and TodayController with POST/GET /api/today/seal endpoints.
Closes the full vertical slice for #1017.
Creates DailySnapshots table with unique index on (UserId, Date)
for the seal-day feature (#1017).
Updates all 9 fake/stub IUnitOfWork implementations in API tests to
include the new IDailySnapshotRepository DailySnapshots member.
Domain tests: construction validation, seal idempotency, future date
rejection, IsSealed property. Service tests: seal new day, seal
already-sealed day (idempotent), empty userId validation, future date
rejection, get-seal-status for missing/sealed/unsealed snapshots.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

Concurrency: two simultaneous seal requests

The unique index IX_DailySnapshots_UserId_Date on (UserId, Date) provides database-level protection. If two requests race to create a snapshot for the same user+date:

  • Both find null: One SaveChangesAsync succeeds, the other gets a DbUpdateException from the unique constraint violation. The UnitOfWork.SaveChangesAsync wraps concurrency exceptions as DomainException(Conflict), which maps to HTTP 409. The client can retry and will hit the idempotent path.
  • Both find existing row: Both call Seal() which is idempotent. The UpdatedAt concurrency token means one save wins and the other gets a concurrency exception (also mapped to 409).
  • One finds null, one finds existing: The creator's save wins; the other may get a unique constraint violation on create, or succeeds on the existing row.

Verdict: Safe. The worst case is a retriable 409 conflict, never data corruption.

Date timezone edge cases

The date parameter is DateOnly (no timezone component). The future-date check uses DateOnly.FromDateTime(now.UtcDateTime) consistently. This means:

  • A user in UTC+13 could seal "tomorrow" in UTC terms, but the system prevents it by comparing against UTC now.
  • The caller decides which calendar day to seal -- the backend does not infer the user's timezone.

Verdict: Correct for a UTC-normalized system. If the product later needs timezone-aware day boundaries, the API contract (DateOnly) supports passing the user's local date without backend changes.

Migration correctness

  • Table schema matches entity properties exactly.
  • Unique composite index prevents duplicate snapshots.
  • Down() drops the table cleanly.
  • Namespace matches the Migrations/ folder convention.

Verdict: Correct.

Idempotency guarantees

  • Domain: Seal() checks IsSealed and returns early if true.
  • Service: Detects wasAlreadySealed before calling Seal() and reports it in the response.
  • Database: Single SaveChangesAsync call is atomic.

Verdict: Strong idempotency at all layers.

No issues found that require fixing.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the 'Daily Seal' feature, which allows users to mark a specific date as sealed. The changes include the new DailySnapshot domain entity, a corresponding repository, and the DailySealService to handle sealing logic and status checks. A new TodayController provides the API endpoints for these operations. Feedback focuses on enhancing the robustness of the implementation by handling unique constraint violations for daily snapshots within the UnitOfWork to prevent unhandled exceptions during concurrent requests. Additionally, it is recommended to propagate CancellationToken throughout the asynchronous call stack, from the controller actions down to the repository layer, to ensure proper request cancellation support.

Comment thread backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs
Comment thread backend/src/Taskdeck.Application/Services/DailySealService.cs Outdated
Comment thread backend/src/Taskdeck.Application/Services/DailySealService.cs Outdated
Comment thread backend/src/Taskdeck.Application/Services/IDailySealService.cs Outdated
Comment thread backend/src/Taskdeck.Api/Controllers/TodayController.cs Outdated
Comment thread backend/src/Taskdeck.Api/Controllers/TodayController.cs Outdated
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review -- Findings & Fixes

Reviewed all 24 files in this PR. Addressing the 6 Gemini bot comments plus additional verification.

Issues Found & Fixed

HIGH: Missing DailySnapshot unique constraint handler in UnitOfWork.cs

  • TryResolveRecoverableUniqueConflicts handled Notifications and UserPreferences but NOT DailySnapshots.
  • Concurrent seal requests hitting the IX_DailySnapshots_UserId_Date unique index would throw unhandled DbUpdateException (500).
  • Fix: Added TryResolveDuplicateDailySnapshotConflicts and IsDailySnapshotUniqueViolation methods following the established pattern. Wired into TryResolveRecoverableUniqueConflicts.

MEDIUM: Missing CancellationToken propagation (4 locations)

  • IDailySealService.SealDayAsync / GetSealStatusAsync -- no CancellationToken param.
  • DailySealService.SealDayAsync / GetSealStatusAsync -- no CancellationToken param or propagation.
  • TodayController.SealDay / GetSealStatus -- no CancellationToken accepted from ASP.NET pipeline.
  • Fix: Added CancellationToken cancellationToken = default to interface and service methods. Controller now accepts CancellationToken from the framework and propagates it. All downstream async calls (GetByUserAndDateAsync, AddAsync, SaveChangesAsync) now receive the token. Tests updated to use It.IsAny<CancellationToken>().

Additional Checks (All OK)

  • Repository methods: IDailySnapshotRepository already accepts CancellationToken -- no change needed.
  • EF Migration: Table and IX_DailySnapshots_UserId_Date unique index are correctly defined.
  • Idempotency: DailySnapshot.Seal() is a no-op when already sealed; SealDayAsync correctly reports WasAlreadySealed=true and preserves the original timestamp. Truly idempotent.
  • EF Configuration: DailySnapshotConfiguration correctly defines the unique index and concurrency token.
  • DI Registration: Both DailySnapshotRepository and DailySealService are properly registered.

Build & Test Results

  • dotnet build backend/Taskdeck.sln -c Release -- 0 errors
  • 28 seal/snapshot tests -- all pass (16 domain, 12 application)

…nToken through seal flow

Fix concurrent seal requests causing unhandled 500 errors by adding
DailySnapshot to UnitOfWork.TryResolveRecoverableUniqueConflicts,
matching the existing Notification/UserPreference pattern.

Add CancellationToken to IDailySealService, DailySealService,
and TodayController so the ASP.NET pipeline can cancel in-flight
seal requests. Update test mock setups to use It.IsAny<CancellationToken>().
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Fresh Adversarial Review (Round 3)

CRITICAL

1. Concurrent seal race returns fabricated response data (Confidence: 95)
When two concurrent seal requests race on the same (UserId, Date):

  • Both find no existing snapshot, both create and AddAsync
  • Loser hits unique constraint, TryResolveDuplicateDailySnapshotConflicts detaches the entity
  • Retry SaveChangesAsync succeeds (saves nothing)
  • Service returns DailySealResponse(snapshot.SealedAt!.Value, wasAlreadySealed: false) using the detached, in-memory snapshot

The losing request reports WasAlreadySealed: false when it was actually sealed by the other request, and returns its own in-memory SealedAt timestamp rather than the actual persisted value.

Fix: After SaveChangesAsync, re-fetch the snapshot from the database. Or catch DomainException(Conflict) at the service level, re-query, and return accurate WasAlreadySealed: true.

IMPORTANT

2. No controller-level integration tests (Confidence: 85) — Domain and service tests exist (28 tests), but no HTTP pipeline test verifying routing, claims extraction, or status code mapping for POST /api/today/seal and GET /api/today/seal.

Verified Non-Issues

  • Security: claims-first identity, future date prevention at both domain and service levels
  • Idempotency correct for non-concurrent path (second seal is no-op preserving original timestamp)
  • Migration: proper unique composite index on (UserId, Date), clean Down() method
  • CancellationToken threaded through all layers
  • Error contracts: ErrorCodes.ValidationError mapped to 400 consistently

Chris0Jeky and others added 7 commits April 26, 2026 21:10
After SaveChangesAsync, re-fetch the snapshot from the database to
detect when TryResolveDuplicateDailySnapshotConflicts detached our
in-memory entity. If the persisted snapshot has a different Id, the
other request won the race — return WasAlreadySealed: true with the
winner's SealedAt timestamp instead of stale in-memory values.
Resolve add/add conflict in TodayController.cs by combining both
the cadence endpoint (from main via PR #1031) and the seal-day
endpoints (from this branch). Both ICadenceService and
IDailySealService are now injected via constructor DI.
# Conflicts:
#	backend/src/Taskdeck.Api/Controllers/TodayController.cs
#	frontend/taskdeck-web/tests/e2e/smoke.spec.ts
@Chris0Jeky Chris0Jeky merged commit bb35128 into main Apr 27, 2026
31 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Apr 27, 2026
@Chris0Jeky Chris0Jeky deleted the paper/1017-seal-day branch April 27, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

paper-today-backend-gap-seal-day-action

1 participant